# 拓扑图实现导出大数据量时的图片
# 背景
由于浏览器对canvas有最大尺寸限制,导致在大数据量时(canvas很宽很宽)会导出图片失败,需要想别的办法去解决。
# 方案一:限制最大尺寸
最开始了解到canvas有最大尺寸后,想到的方案是给一个最大尺寸,将canvas的包围盒尺寸按比例缩小。
# 等比缩小
这里给个经验值20000。
// 做个最大尺寸的限制,避免超出后截不全
const oWidth = vWidth;
// 最大高度
const maxHeight = 20000;
// 最大宽度
const maxWidth = 20000;
if (vWidth > maxWidth && vHeight > maxHeight) {
  // 更宽更高,
  if (vHeight / vWidth > maxHeight / maxWidth) {
    // 更加严重的高窄型,确定最大高,压缩宽度
    vHeight = maxHeight;
    vWidth = maxHeight * (vWidth / vHeight);
  } else {
    // 更加严重的矮宽型, 确定最大宽,压缩高度
    vWidth = maxWidth;
    vHeight = maxWidth * (vHeight / vWidth);
  }
} else if (vWidth > maxWidth && vHeight <= maxHeight) {
  // 更宽,但比较矮,以maxWidth作为基准
  vWidth = maxWidth;
  vHeight = maxWidth * (vHeight / vWidth);
} else if (vWidth <= maxWidth && vHeight > maxHeight) {
  // 比较窄,但很高,取maxHight为基准
  vHeight = maxHeight;
  vWidth = maxHeight * (vWidth / vHeight);
} else {
  // 符合宽高限制,不做压缩
}
# 改变矩阵运算的缩放值
matrix的0和4位是缩放比例,这里除以之前缩小的比例得到正确的值。
const rate = oWidth / vWidth;
matrix[0] /= rate;
matrix[4] /= rate;
# 导出
之后就是按照g6.js的api去导出图片即可。
# 解决图片模糊问题
这里通过改变设备的像素比来解决。
导出前:
window.devicePixelRatio = 3;
导出后恢复原像素比。
# 缺点
这种方案在设备数量少的时候效果还是可以的,当数量大了后canvas实在是太大了,3000台设备能达到50000+px的尺寸,导致被过量压缩,清晰度下降甚至会看不清。
# 方案二:分批绘制后导出
既然是超出canvas限制尺寸了,那就缩小尺寸吧。
# 判断尺寸
const { width, height } = this.get('group').getCanvasBBox();
通过获取包围盒的尺寸,与设置的最大尺寸(20000,经验值)比较,大于则分批绘制数据然后导出图片,否则采用方案一直接导出。
# 切割数据
通过以下方法将画布切割成一个二维数组
const splitArr = Array.from(
  new Array(Math.ceil(height / this.MAX_CANVAS_SIZE)),
  () => Array.from(
    new Array(Math.ceil(width / this.MAX_CANVAS_SIZE)),
    () => ({ nodes: [], edges: [], display_options: this.parent._options.data.display_options })
  )
);
然后找出在该区域的节点
const { nodes, edges } = this.save();
for (let rowIndex = 0; rowIndex < splitArr.length; rowIndex++) {
  for (let columnIndex = 0; columnIndex < splitArr[0].length; columnIndex++) {
    const areaMinX = minX + columnIndex * this.MAX_CANVAS_SIZE;
    const areaMinY = minY + rowIndex * this.MAX_CANVAS_SIZE;
    const areaMaxX = areaMinX + this.MAX_CANVAS_SIZE;
    const areaMaxY = areaMinY + this.MAX_CANVAS_SIZE;
    const nodesMap = new Map();
    nodes.forEach(node => {
      const { x, y, id } = node;
      // 在该区域内
      if (x >= areaMinX && x < areaMaxX &&
        y >= areaMinY && y < areaMaxY &&
        this.findById(id).isVisible()
      ) {
        splitArr[rowIndex][columnIndex].nodes.push(node);
        nodesMap.set(id, 1);
      }
    });
    edges.forEach(edge => {
      const { source, target } = edge;
      if (nodesMap.has(source) || nodesMap.has(target)) {
        splitArr[rowIndex][columnIndex].edges.push(edge);
      }
    });
  }
}
# js队列
下面的方法需要用到队列,这里手动实现一个。
原理就是用数组记录一个promise队列,使用时等前一个结束后再执行下一个。
/**
 * promise队列
 */
export default class Queue {
  constructor() {
    this.queue = [];
  }
  push(task) {
    this.queue.push(task);
  }
  execute() {
    const res = [];
    return new Promise(resolve => {
      this.loop(res, resolve);
    });
  }
  loop(res, resolve) {
    const nextTask = this.queue.shift();
    if (nextTask) {
      nextTask().then((result) => {
        res.push(result);
        this.loop(res, resolve);
      });
    } else {
      resolve(res);
    }
  }
}
# 分批渲染
# 创建个新拓扑实例
const graphOptions = cloneDeep(this.parent._options);
const div = document.createElement('div');
div.style.width = `${graphOptions.width}px`;
div.style.height = `${graphOptions.height}px`;
div.style.position = 'absolute';
div.style.left = '-999999999px';
document.body.appendChild(div);
graphOptions.container = div;
graphOptions.data = null;
graphOptions.devMode.mock.enable = false;
graphOptions.plugins = graphOptions.pluginsOption;
const newGraph = new UEDGraph(graphOptions);
# 创建队列
const queue = new Queue();
// 将之前的二维数组循环推入
queue.push(async (resolve) => {
  newGraph.graph.clear();
  newGraph.graph.on(CUSTOM_EVENT.updateEnd, async () => {
    await this.sleep(2000);
    await newGraph.graph.downloadFullImage(options);
    resolve();
  }, true);
  await newGraph.updateGraph(
    splitArr[rowIndex][columnIndex],
    {
      needCalculatePosition: false,
      showMaxNodeBranch: false,
      autoFit: false,
      showLoading: false,
      needTransform: false
    }
  );
});
在渲染完数据后执行图片的导出。
# 执行
await queue.execute();
document.body.removeChild(div);
newGraph.destroy();
# 缺点
这种方案的问题还是比较多的。
- 当连线设备在范围外时,会将这部分连线丢失。
 - 截出来的图片尺寸不一致
 
由于问题1无法解决,所以放弃了这种方案。
# 方案三:使用puppeteer截图
# 安装
npm i puppeteer-core
由于开发环境在内网,还需要手动下载chrome包
下载教程可参考http://www.qb5200.com/article/346953.html
# 使用
const puppeteer = require('puppeteer-core');
const path = require('path');
 
(async () => {
 const browser = await puppeteer.launch({
  // 这里注意路径指向可执行的浏览器。
  // 各平台路径可以在 node_modules/puppeteer-core/lib/BrowserFetcher.js 中找到
  // Mac 为 '下载文件解压路径/Chromium.app/Contents/MacOS/Chromium'
  // Linux 为 '下载文件解压路径/chrome'
  // Windows 为 '下载文件解压路径/chrome.exe'
  executablePath: path.resolve('./chrome-win/chrome.exe')
 });
 const page = await browser.newPage();
 await page.setViewport({
  width: 1920,
  height: 1080
});
 await page.goto('http://127.0.0.1:8080/example/tree/', {
  waitUntil: 'networkidle0',
  timeout: 0
 });
//  await page.screenshot({
//   path: 'marx-blog.png',
//   fullPage: true,
// });
let canvas = await page.$('#ued-graph canvas');
//调用页面内Dom对象的screenshot 方法进行截图
canvas.screenshot({
    path:'form.png'
});
 await browser.close();
})();
可以直接对页面截图,或者指定dom。
尝试了都只能对当前显示内容进行截图,无法去滚动canvas中的内容。失败告终
# 方案四:做矩阵运算
G6.js可以导出可视区域和整个图的图片,于是通过查看源码,发现导出可视区域的原理就是canvas只支持将显示了的元素导出。整个图是通过重新渲染一个canvas,然后将所有元素的包围盒的尺寸赋给它,即让这个虚拟的canvas直接显示所有的元素,然后再通过toDataURL导出。
# 原理
知道原理后,就想到了下面这招:
给定最大尺寸,超出尺寸的不像方案一一样去等比缩小,而是对拓扑做偏移。
相当于在有限的尺寸上显示部分元素,导出后再做下一个区域的偏移,直到将所有区域完成导出。
# 实现
# 判断尺寸
同样,判断尺寸,超出做分批导出的操作
# 分批
const { width } = this.get('group').getCanvasBBox();
const length = Math.ceil(width / this.MAX_CANVAS_SIZE);
const maxWidth = width / length;
const queue = new Queue();
for (let i = 0; i < length; i++) {
  queue.push(async () => {
    return await this.downloadFullImage({
      ...options,
      maxWidth,
      splitIndex: i,
      download: true
    });
  });
}
return await queue.execute();
循环需要分批的次数去截图。
这里对G6的downloadFullImage做了重写,使用promise包裹。
调用时根据maxWidth字段创建的虚拟canvas的宽度。
const realWidth = maxWidth || vWidth;
const canvasOptions = {
  container: vContainerDOM,
  height: vHeight,
  // 有给最大宽度就用最大的
  width: realWidth
};
通过splitIndex做矩阵运算的偏移
// 根据切割的索引往左做偏移
if (splitIndex !== undefined) {
  matrix[6] = maxWidth * splitIndex * -1;
}
返回的格式改为状态和base64格式。
[status, dataURL]
# 服务端实现图片拼接
成功导出图片后,就需要服务端去做图片的拼接。
因为前端拼接图片也是通过canvas去实现,这里导出的图片本来就超过canvas的尺寸限制了,所以得借助服务端的能力去实现。
至此,终于实现了大数据量下的canvas图片导出功能。